walletrpc: add XCreateAccount for wallet-derived accounts - #11065
Conversation
b4e3c93 to
b5bd4e5
Compare
🔴 PR Severity: CRITICAL
🔴 Critical (4 files)
🟠 High (5 files)
🟡 Medium (4 files)
🟢 Low (3 files)
AnalysisThis PR adds a new WalletKit RPC and plumbs it through To override, add a |
litbot-9000
left a comment
There was a problem hiding this comment.
Reviewed at commit b5bd4e5, built and tested locally against the pinned btcwallet v0.18.0 in the module cache.
The shape of this is right: ImportAccount genuinely cannot produce a spendable account, NextAccount is the correct primitive, and the guard rails you picked are the ones that matter. Most of the load-bearing claims in the description check out (details below). One blocking item, in the docs rather than the code.
What I verified, and how
- Each of the 5 commits builds standalone.
git checkout <commit> && go build ./... && go vet ./lnwallet/... ./lnrpc/walletrpc/...at each of48abc65,43524c3,7b5955c,c9a917d,b5bd4e5— all exit 0. - Tests pass.
go test ./lnwallet/btcwallet/... ./lnrpc/walletrpc/... ./lnwallet/rpcwallet/...— all ok. make rpc-checkI could NOT run. It shells out togen_protos_docker.shand there is no docker or protoc in this container (make: *** [Makefile:446: rpc] Error 127). I did not verify the generated stubs match the proto; I am taking your word that it passes.- Duplicate rejection really does cover every scope, and is not hardcoded.
ListAccounts(name, nil)with a custom name andlookupFirstCustomAccount(lnwallet/btcwallet/psbt.go:627) both iterate the samewaddrmgr.DefaultKeyScopes, so a scope added upstream propagates to both automatically. Note that slice is{49+, 84, 86, 44}— wider than lnd's ownLndDefaultKeyScopes— which is the conservative direction. Claim holds. NESTED_WITNESS_PUBKEY_HASHrejection is correct.waddrmgr.ScopeAddrMap[KeyScopeBIP0049Plus]is{External: NestedWitnessPubKey, Internal: WitnessPubKey}, andmarshalWalletAccountmaps a nilAddrSchemaunder that scope toHYBRID_...(walletkit_server.go:2544).NextAccounthas noaddrSchemaparameter, so the strict type is genuinely unrepresentable. Rejecting rather than substituting is the right call.RPCKeyRingis the only embedder that needed an override. I grepped for embedders oflnwallet.WalletController: there are exactly two,RPCKeyRing(rpcwallet.go:58) andlnwallet.LightningWallet(wallet.go:419). The latter promotes whatever controller is configured, and under remote signingconfig_builder.go:891wiresrpcKeyRingin asWalletController, socc.Wallet.CreateAccountreaches your override rather thanBtcWallet's. No latent promotion left.- Macaroons and REST.
onchain/writematchesImportAccount/ImportPublicKey;POST /v2/wallet/accounts/createis consistent with/v2/wallet/accounts/import. Both fine. - The seed-only recovery caveat is real. btcwallet's
RecoveryManager.Resurrectrederives only forwaddrmgr.DefaultAccountNum(wallet/recovery.go:72,wallet/wallet.go:1110and:1152), with a standing upstream TODO right there:// TODO(conner): rescan for all created accounts if we allow users to use non-default address. Confirmed.
The blocking item
The caveat is correctly identified, but the mitigation the proto gives does not work, and an operator who follows it literally will conclude their coins are gone when they are recoverable. Details inline on walletkit.proto. This is docs-only, but it is the difference between "annoying manual procedure" and "funds lost", on an API that is permanent once released and that is about to hold real mainnet coins. Worth getting exactly right before it ships rather than in a follow-up.
A proto comment plus a release note is, in my view, sufficient warning — I do not think this needs an lncli y/N confirmation prompt, and I would not want one. But the warning has to be actionable, and lncli should surface it at all (it currently does not).
Your two questions
(a) itest + HarnessRPC helper — yes, in this PR. The unit tests are good at what they cover (guards, scope forwarding, error wrapping) but they run against a hand-rolled fake that never touches waddrmgr, so the one claim the whole PR exists to make — this account, unlike an imported one, can actually sign for its own outputs — is not exercised anywhere in CI. "Exercised end-to-end downstream" does not help upstream lnd, and for what it's worth neither lightninglabs/wavelength#1140 nor lightninglabs/lumos#786 covers the account path end to end either, so if it does not land here it exists nowhere. A single itest doing create → NextAddr → fund → FundPsbt → SignPsbt → confirm would also pin down the address-type round-trip in the second inline comment below, which is the part I would most expect to regress.
(b) btcwallet PR vs local assertion — do both, in that order of importance, but do not block this PR on it. Your replace-doesn't-propagate reasoning is correct: Go ignores replace directives in non-main modules, so a fork here would silently do nothing for every downstream consumer of lnd and each would have to duplicate it. That is a real cost and the assertion is the right short-term call. It also degrades safely — I checked, *wallet.Wallet satisfies it, and a backend that does not gets a clear typed error rather than a panic. Open the btcwallet PR to widen base.Interface in parallel and drop the assertion on the next dep bump; the TODO comment already says as much.
What I did NOT verify
make rpc-check/ generated stub correctness (no toolchain here).- No itest run; I have not observed a created account actually receive and spend coins on regtest. Everything I say about spendability is from reading
waddrmgr/btcwallet, not from running it. - The two sibling PRs are in private repos I did not read.
— claudell ⚡
| NOTE: Funds held in an account created here are not rediscovered by a | ||
| seed-only recovery. lnd's recovery scan only derives addresses for the | ||
| wallet's default account, so restoring from the aezeed alone will not | ||
| find them; back up the account name alongside the seed and re-create the |
There was a problem hiding this comment.
Blocking: the caveat is right but the recovery procedure given here does not work, in two separate ways. Someone following it exactly will rescan, find nothing, and reasonably conclude the coins are unrecoverable.
-
The account name is not part of the derivation — the account index is.
ScopedKeyManager.NewAccountassignsfetchLastAccount(ns, &s.scope) + 1(waddrmgr/scoped_manager.go:1625-1633), andNewAccountWatchingOnlyshares that same counter (:1742). So backing up the name buys you nothing: what has to be reproduced is the key scope and the account index, which means re-creating every account in that scope — including anyImportAccountones — in the original order, so the counter lands on the same value. Two accounts recreated in the wrong order gives you two accounts with each other's addresses. -
Re-creating the account is not sufficient, because a rescan only looks for addresses already in the wallet DB.
Wallet.activeDatafeeds the rescan fromManager.ForEachRelevantActiveAddress(waddrmgr/manager.go:827), i.e. addresses that have already been derived. A freshly created account hasExternalKeyCount == 0, so a rescan over it searches for zero addresses. The user also has to re-derive at least as many addresses as were previously used —NextAddrin a loop against the recreated account — before triggering the rescan.
So the accurate procedure is roughly: record the key scope, the account index, and the number of addresses issued; on restore, re-create accounts in that scope in original order until the index matches, call NextAddr at least that many times, then start with --reset-wallet-transactions. That is a genuinely recoverable situation, which is a much better story than the current text tells — worth spelling out rather than leaving as a flat "will not rediscover".
It is also worth citing the reason it is this way, since it bounds how permanent the caveat is: btcwallet's RecoveryManager.Resurrect hardcodes waddrmgr.DefaultAccountNum (wallet/recovery.go:72) and carries an explicit // TODO(conner): rescan for all created accounts if we allow users to use non-default address — which this RPC is precisely the trigger for. Might be worth opening that btcwallet issue alongside the base.Interface one.
| NOTE: The account's address type is permanent and also fixes the type of | ||
| its change outputs. lnd resolves a custom account name within the key | ||
| scope implied by the requested address type, so later calls such as | ||
| NextAddr must ask for the same address type or the account will appear |
There was a problem hiding this comment.
"later calls such as NextAddr must ask for the same address type" is not followable for one of the three types you accept. NewAddress/NextAddr take lnrpc.AddressType, which has no HYBRID_* member — it is {WITNESS_PUBKEY_HASH, NESTED_PUBKEY_HASH, TAPROOT_PUBKEY, ...} (lnrpc/lightning.proto:1193). An account created as HYBRID_NESTED_WITNESS_PUBKEY_HASH has to be addressed with NESTED_PUBKEY_HASH, since rpcserver.go:1698 maps that to lnwallet.NestedWitnessPubKey → KeyScopeBIP0049Plus in keyScopeForAccountAddr. Suggest naming the mapping explicitly rather than saying "the same address type".
Separately, and the reason this caveat keeps costing people time: the failure mode is a bare not-found. keyScopeForAccountAddr (lnwallet/btcwallet/btcwallet.go:470) calls AccountNumber(addrKeyScope, accountName) and returns waddrmgr's account name 'x' not found verbatim — no hint that the account exists, just under a different scope. Given this same edge has now had to be worked around independently in lightninglabs/wavelength#1140 and lightninglabs/lumos#786 and documented a third time here, the error message is arguably the actual bug. A cheap fix in the not-found branch: fall back to lookupFirstCustomAccount, and if the name does resolve under another scope, say so — account "x" exists under key scope %v, not %v; request address type %v instead. Non-blocking, but it would retire the footgun instead of documenting it a fourth time.
| // lookupFirstCustomAccount, which returns whichever scope happens to | ||
| // match first, so the same name existing under two scopes would make | ||
| // every later funding call for that name ambiguous. | ||
| _, err := b.ListAccounts(name, nil) |
There was a problem hiding this comment.
Verified the reasoning here: ListAccounts(name, nil) on the custom-name path and lookupFirstCustomAccount both range over waddrmgr.DefaultKeyScopes, so the check covers exactly the set that could later become ambiguous, and a scope added upstream propagates to both without touching this code. Good.
One gap worth a line of acknowledgement: this check and the NextAccount below are separate db transactions, and btcwallet's own duplicate check (newAccount → lookupAccount, scoped_manager.go:1654) is per-scope. Two concurrent CreateAccount calls with the same name under different scopes therefore both pass here and both succeed, producing exactly the ambiguity this guard exists to prevent. Realistically an operator does not race themselves, so I would not restructure for it — but a NOTE: saying the cross-scope invariant is best-effort and not atomic would stop the next reader assuming it is enforced.
| // propagate to modules that depend on lnd, and would have to be | ||
| // duplicated by every one of them. This assertion can be dropped once | ||
| // NextAccount is part of base.Interface upstream. | ||
| creator, ok := b.wallet.(accountCreator) |
There was a problem hiding this comment.
Checked the degradation path since it is the thing an interface assertion usually gets wrong: b.wallet is a base.Interface, the concrete *wallet.Wallet satisfies NextAccount (btcwallet@v0.18.0 wallet/wallet.go:2090), and anything else gets this typed error rather than a nil-deref. The %T is a nice touch for diagnosing it. No objection to landing it as-is.
On the choice itself, answering the question in the description: the replace argument is correct and is the deciding factor — Go ignores replace directives in non-main modules, so forking btcwallet here would be a no-op for every consumer of lnd and each would have to carry its own copy. Widening base.Interface upstream is still the right end state; open that PR in parallel and drop this on the next btcwallet bump, which is what the comment already commits to.
| func (r *RPCKeyRing) CreateAccount(waddrmgr.KeyScope, | ||
| string) (*waddrmgr.AccountProperties, error) { | ||
|
|
||
| return nil, fmt.Errorf("creating accounts is not supported when " + |
There was a problem hiding this comment.
Confirmed this is needed and that it is the only place that needs it — RPCKeyRing and lnwallet.LightningWallet (lnwallet/wallet.go:419) are the only two embedders of WalletController, and under remote signing config_builder.go:891 installs rpcKeyRing as LightningWallet.WalletController, so cc.Wallet.CreateAccount lands here. Without it you would indeed have gotten ErrWatchingOnly from scoped_manager.go:1611, which is exactly the unhelpful bare error you describe. Error text points at the right alternative too.
Only nit: this returns a plain error, so it surfaces over gRPC as Unknown. status.Error(codes.Unimplemented, ...) would let callers branch on it — mildly useful for the downstream consumers, which have to decide whether to fall back to the default account. Take it or leave it.
|
|
||
| The address type permanently fixes the key scope the account lives in, | ||
| and therefore the address type of both its receive and its change | ||
| outputs. It defaults to taproot and cannot be changed afterwards. |
There was a problem hiding this comment.
The description covers the permanence of the address type but says nothing about the recovery caveat, which is the part that can actually cost someone money. I do not think this needs an interactive y/N — that gets in the way of scripted operator setup and people click through prompts anyway — but the caveat should at least appear here, and ideally be echoed to stderr after a successful create, along the lines of: funds in this account are not found by a seed-only restore; record the account's key scope and index and see before depositing.
That also makes the lncli output the place the operator learns the index, which per the note on walletkit.proto is the thing they actually need to have written down. printRespJSON(resp) already returns the Account with its derivation_path, so it is right there — just worth pointing at.
9dd8bad to
3117a4d
Compare
| // names of the wallet's built-in accounts ("default" and "imported") are | ||
| // reserved and cannot be used. | ||
| string name = 1; | ||
|
|
There was a problem hiding this comment.
Wondering if we should expose the ability to set an explicit key family/account here. As in other codebases we may already have a region carved out.
If say keyfam 77 is taken, and we allow it to be specified, what would be the resulting behavior?
There was a problem hiding this comment.
Good question, and the answer is reassuring: key families and accounts are different namespaces, so a taken keyfam can't collide here.
lnd's own keys live under the BIP-0043 purpose 1017 scope (keychain.BIP0043Purpose), where the account slot is what carries the KeyFamily — so keyfam 77 is m/1017'/<coin>'/77'. CreateAccount only ever writes into the BIP-49Plus/84/86 scopes (whichever the requested address type maps to), so an account created here is m/86'/0'/N' and can never land on m/1017'/…/77'. They can't overlap, and ListAccounts doesn't surface 1017 accounts as named accounts either.
On exposing the index explicitly: I'd like to, but btcwallet doesn't currently allow it. ScopedKeyManager.NewAccount assigns fetchLastAccount + 1 unconditionally — there's no "create at index N" entry point, and the same counter is shared with ImportAccount. So accepting an explicit index would mean either a btcwallet change or creating-and-discarding accounts until the counter lands where you asked, which seems worse than not offering it.
That matters more than it first looks, because the index — not the name — is what a seed-only recovery needs (the recovery scan only rederives account 0, so restoring a created account means reproducing its scope and index). The response does return it: Account.derivation_path carries the full path, and lncli now points at it after a successful create for exactly that reason.
If you'd like an explicit index, I'm happy to open the btcwallet PR to add a NewAccountAtIndex-style entry point and follow up here — it'd pair naturally with the base.Interface widening this PR already wants.
| // key scope. | ||
| accountNumber, err := b.wallet.AccountNumber(addrKeyScope, accountName) | ||
| if err != nil { | ||
| // A custom account lives in exactly one key scope, so asking |
There was a problem hiding this comment.
A custom account lives in exactly one key scope
Meaning the BIP 86/84 scope?
There was a problem hiding this comment.
Yes — one of BIP-0049Plus, BIP-0084 or BIP-0086, whichever the address type at creation mapped to, and fixed for the account's lifetime. I've named the three in the comment rather than leaving it implicit.
| // propagate to modules that depend on lnd, and would have to be | ||
| // duplicated by every one of them. This assertion can be dropped once | ||
| // NextAccount is part of base.Interface upstream. | ||
| creator, ok := b.wallet.(accountCreator) |
| // below are separate database transactions, and btcwallet's own | ||
| // duplicate check is per-scope, so two concurrent calls naming the | ||
| // same account under different scopes can both succeed. | ||
| _, err := b.ListAccounts(name, nil) |
There was a problem hiding this comment.
There's a potential race here. List then next isn't under the same db transaction, so another concurrent caller can win over.
There was a problem hiding this comment.
We don't have anything exposed today to handle this in a single unit, so perhaps a mutex is the best we can do here.
There was a problem hiding this comment.
Agreed, and done — added a createAccountMtx held across both the duplicate check and NextAccount. As you say there's nothing that does the two in one db transaction today, so a mutex is what's available; the comment says so explicitly and notes it only serialises callers within this process (lnd is the sole writer of its own wallet).
Also added TestCreateAccountSerialisesCallers, which fails without the mutex — the wallet fake reports the greatest number of callers it ever saw inside the check-then-create section, so it asserts serialisation directly rather than trying to lose a race by chance. Worth flagging because my first attempt at that test still passed with the mutex removed, which made it worthless.
3117a4d to
5d1dabe
Compare
|
One scope clarification that may be worth making explicit in the RPC/CLI docs: the account separation boundary ends at the on-chain wallet layer. Normal and batch channel funding still select from the default account; a custom account can fund a channel through the PSBT flow, but once those UTXOs enter the channel state machine the channel carries no originating-account association, and later sweep outputs generally return to the default account. So this provides useful UTXO/PSBT isolation, but not end-to-end per-application channel or Lightning accounting. The current “isolated pocket of funds” wording could otherwise be read as covering the full channel lifecycle. |
ziggie1984
left a comment
There was a problem hiding this comment.
Had a look at this specifically for races, lock structure and RPC lifecycle. Overall it looks solid — go vet is clean (so the new mutex field doesn't trip copylocks), and go test -race -run TestCreateAccount ./lnwallet/btcwallet/ passes.
No deadlocks. createAccountMtx is a leaf lock: everything under it (ListAccounts -> AccountPropertiesByName, NextAccount, AccountProperties) descends into btcwallet/waddrmgr and never re-enters BtcWallet, so there's no ordering cycle and no re-entrancy back into CreateAccount.
RPC lifecycle is clean. In lncli, parseAddrType runs before getWalletClient, so the validation error path never dials, and defer cleanUp() covers every other path. Both lntest/rpc helpers use context.WithTimeout(h.runCtx, ...) with defer cancel().
The one thing I'd want addressed before merge is the ImportAccount gap below — the new mutex closes the CreateAccount-vs-CreateAccount window but leaves CreateAccount-vs-ImportAccount open, which is the same invariant. Rest is minor.
| // one database transaction, so hold this for both. It only serialises | ||
| // callers within this process; nothing stops a second process driving | ||
| // the same wallet, but lnd is the sole writer of its own wallet. | ||
| b.createAccountMtx.Lock() |
There was a problem hiding this comment.
The mutex closes the CreateAccount-vs-CreateAccount window, but the same cross-scope invariant is still racy against ImportAccount.
BtcWallet.ImportAccount (btcwallet.go:936-950 on this branch) does the identical check-then-act — b.ListAccounts(name, nil) followed by b.wallet.ImportAccount(...) — and takes no lock. Since gRPC handlers run concurrently, CreateAccount("foo", BIP0086) and ImportAccount("foo", ...) can both pass their duplicate checks and both succeed, leaving one name under two key scopes. That's exactly the ambiguity this mutex exists to prevent, just reached from the other side.
Suggest renaming it to accountMtx and taking it in ImportAccount too, wrapping the ListAccounts check through both the dry-run and non-dry-run branches (the dry run also consults ListAccounts before deciding).
Worth noting the ImportAccount half is pre-existing on master, so it could also be a follow-up — but since this PR is the one establishing the invariant, it seems natural to close it here.
There was a problem hiding this comment.
Good catch — closed. Renamed to accountMtx and ImportAccount now takes it too, held across its ListAccounts check and both the dry-run and non-dry-run branches. You're right that it's the same invariant reached from the other side, and that it was worth doing here since this is the PR that establishes the invariant.
| // The wallet creates both of these accounts itself, in every key scope, | ||
| // and neither is backed by a derived account key we could recreate | ||
| // here. | ||
| if name == lnwallet.DefaultAccountName || |
There was a problem hiding this comment.
Nit: the empty-name check sits above Lock() but this reserved-name check is below it, even though it's a pure string comparison that touches no shared state. Would read better with all the input validation grouped before the critical section.
There was a problem hiding this comment.
Moved — the reserved-name check now sits with the empty-name check above Lock(), so all the pure input validation is grouped before the critical section.
| accountName, | ||
| ) | ||
| if lookupErr == nil { | ||
| return waddrmgr.KeyScope{}, 0, fmt.Errorf( |
There was a problem hiding this comment.
Heads up that this converts a typed waddrmgr.ManagerError{ErrAccountNotFound} into an untyped fmt.Errorf on the "exists under a different scope" path, which is a silent contract change for NewAddress/LastUnusedAddress.
Nothing outside lnwallet/btcwallet inspects that code today, so it's safe as-is — and %w wouldn't help anyway since waddrmgr.IsError type-asserts rather than unwrapping. Just worth a line in the commit message so it's not a surprise later.
There was a problem hiding this comment.
Noted in the commit message, thanks. It now says plainly that keyScopeForAccountAddr reports a wrong-scope lookup as an untyped error naming the scope the account does live in, rather than passing through waddrmgr's typed ErrAccountNotFound, and why that's safe today.
| func (r *RPCKeyRing) CreateAccount(waddrmgr.KeyScope, | ||
| string) (*waddrmgr.AccountProperties, error) { | ||
|
|
||
| return nil, status.Error(codes.Unimplemented, "creating accounts is "+ |
There was a problem hiding this comment.
Two things about returning a gRPC status from the wallet layer here:
- The file's convention for unsupported-in-remote-signing ops is a plain error — see
ErrRemoteSigningPrivateKeyNotAvailableat the top of this file. codes.Unimplementedhas a specific meaning in gRPC: "the server does not implement this method". Clients and version-negotiation logic routinely read it as "peer is too old" and fall back accordingly. Here the method is implemented — it's the node's configuration that forbids it.codes.FailedPreconditionconveys that accurately, or just a plain error to match the rest of the file.
There was a problem hiding this comment.
Agreed on both counts, and the second is the stronger argument — the method is implemented, it's the configuration that forbids it, so Unimplemented would actively mislead version negotiation. Switched to a plain error following the file's convention: a package-level ErrRemoteSigningAccountCreation alongside ErrRemoteSigningPrivateKeyNotAvailable.
| // outputs. That makes it usable as an isolated pocket of funds inside a single | ||
| // wallet, because coin selection, change, balance and address derivation can | ||
| // all be scoped to it by name. | ||
| func (w *WalletKit) CreateAccount(_ context.Context, |
There was a problem hiding this comment.
The handler drops the context, which is consistent with the other WalletKit handlers — but this one mutates persistent state, so the consequence is a bit sharper: a client that hits its deadline or cancels still gets the account created, and its retry then fails with "already exists" with no way to distinguish that from a genuine name clash.
Not worth plumbing a context through WalletController for, but a NOTE in the proto docs that the call isn't idempotent and that "already exists" may be the result of a retried-but-successful create would save someone a debugging session.
There was a problem hiding this comment.
Added to the proto docs: the call is not idempotent, the account is created before the response is sent, so a cancelled or timed-out client may still have had it created and its retry will fail with "already exists" indistinguishably from a real clash — with a pointer to check ListAccounts before retrying.
| func (w *serialisingWallet) AccountPropertiesByName(_ waddrmgr.KeyScope, | ||
| name string) (*waddrmgr.AccountProperties, error) { | ||
|
|
||
| w.enter() |
There was a problem hiding this comment.
enter()/exit() only instrument AccountPropertiesByName, so maxInFlight() == 1 proves the check is serialised — not that the check and the creation sit in the same critical section.
A lock that wrapped only the ListAccounts call would pass this test while leaving the actual bug intact, and check+create atomicity is the whole property the mutex exists for. Instrumenting NextAccount with the same enter()/exit() pair would close that.
There was a problem hiding this comment.
You're right, and this is the more useful half of the property — the test as written would have passed a lock that covered only the lookup. NextAccount is now instrumented with the same enter()/exit() pair. I verified it both ways: it fails when the lock is narrowed to just the ListAccounts call, and passes with the lock spanning check and create.
| w := &BtcWallet{wallet: fake} | ||
|
|
||
| var wg sync.WaitGroup | ||
| for i := 0; i < callers; i++ { |
There was a problem hiding this comment.
Style: for i := range callers per the repo convention. The go func(i int) parameter is also unnecessary on Go >= 1.22 now that the loop var is per-iteration.
There was a problem hiding this comment.
Done — for i := range callers, and dropped the now-unnecessary loop-variable parameter.
|
|
||
| // The spend confirmed, so the account still holds its funds minus | ||
| // fees, and the default account is still untouched by any of it. | ||
| ht.AssertWalletAccountBalance( |
There was a problem hiding this comment.
The comment says "the account still holds its funds minus fees", but only the default account is asserted here — the custom account's post-spend balance never gets checked.
That's the strongest post-condition in the test: it's what proves the spend came out of the account and the change went back into it rather than leaking to the default account. Worth an AssertWalletAccountBalance on createAccountName too (or trimming the claim from the comment).
There was a problem hiding this comment.
Added rather than trimmed, since you're right that it's the strongest post-condition. The test now reads the account's own confirmed balance back from WalletBalance's per-account map and asserts it is below the funded amount (a fee was paid) but above funded-minus-a-fee-ceiling — which is what shows the inputs came from the account and the change returned to it, not to default.
| return cli.ShowCommandHelp(ctx, "create") | ||
| } | ||
|
|
||
| addrType, err := parseAddrType(ctx.String("address_type")) |
There was a problem hiding this comment.
parseAddrType maps np2wkh to NESTED_WITNESS_PUBKEY_HASH, which the server unconditionally rejects. The flag's own usage string already omits it, so the CLI accepts a value it knows will fail.
Rejecting it locally would give the user the better "use np2wkh-p2wkh instead" message without the round trip.
There was a problem hiding this comment.
Done — lncli now rejects np2wkh locally with the "use np2wkh-p2wkh instead" message, before dialling.
Yeah that's intended, this is for on-chain wallet isolation mainly. |
1abeff2 to
5048e99
Compare
Declares the RPC and its messages, and regenerates the stubs. The implementation follows in the next commits. The account's address type selects the BIP-0043 key scope it is created under, which is permanent and also fixes the address type of its change outputs, so the proto spells out both that pairing and the fact that a seed-only recovery does not rediscover funds held in such an account. The X prefix follows XImportMissionControl and the XAddLocalChanAliases family: it marks the API as experimental, so it may change or be removed without the usual deprecation period. It comes off once a seed-only recovery can find these accounts.
Adds the wallet-side operation the RPC will call, implemented by BtcWallet through btcwallet's Wallet.NextAccount. Unlike ImportAccount, which registers a watch-only account from an extended public key and whose inputs the wallet can therefore never sign, the account created here is derived from the wallet's master key and is fully spendable. NextAccount is reached through a local interface assertion rather than by widening btcwallet's base.Interface, so lnd does not have to carry a forked btcwallet: a replace directive here would not propagate to modules that depend on lnd and each of them would have to duplicate it. Duplicate names are rejected across every key scope, not just the requested one, because coin selection resolves a custom account through lookupFirstCustomAccount, which returns whichever scope matches first; the same name under two scopes would make later funding calls ambiguous. The wallet's own reserved names are refused too. RPCKeyRing refuses the operation outright. It embeds the WalletController interface, so it would otherwise promote this implementation and fail deep inside waddrmgr with a bare "watching-only wallet"; creating the account on the remote signer and importing its extended public key is the supported path. The same lock is taken by ImportAccount, which does the identical check-then-act against the same namespace and would otherwise let a concurrent pair create one name under two key scopes from the other side of the invariant. Note one deliberate contract change: keyScopeForAccountAddr now reports a wrong-scope lookup as an untyped error naming the scope the account does live in, rather than passing through waddrmgr's typed ErrAccountNotFound. Nothing outside this package inspects that code, and the bare "not found" it replaces is the reason this edge has had to be worked around several times downstream.
Maps the requested address type onto the key scope the account is created in, mirroring ListAccounts, and defaults an unset type to taproot. NESTED_WITNESS_PUBKEY_HASH is rejected rather than served. An account derived by the wallet stores no address schema of its own, so BIP-0049Plus always behaves as the hybrid scheme; honouring the strict request is impossible here and silently substituting the hybrid one would return an account whose change outputs are not what the caller asked for. ImportAccount can honour the distinction because it passes an address schema through. Creation is additionally gated on an explicit acknowledgement, following AbandonChannel: a dev build passes, and a release build requires i_know_what_i_am_doing. Funds held in a created account are not rediscovered by a seed-only restore, so a caller has to state that it accepts that before one is made. The X prefix alone does not carry that, and a dev-build-only gate would not either: release images are what real deployments run, so it would have put the RPC out of reach of exactly the nodes that need the isolation. The acknowledgement keeps it unreachable by accident while leaving it usable by an operator who has read what they are signing up for.
5048e99 to
4caca49
Compare
|
Pushed, addressing @ziggie1984's review and the thread about hiding this until there's a proper recovery flow. Fixups autosquashed into their original commits, so the six-commit structure is unchanged. Experimental, in two waysThe RPC is now On top of that, it requires
I first gated purely on The recovery caveat is stated in the proto, the Nothing here ships to users regardless until btcwallet's recovery scan covers non-default accounts ( Happy to swap the gate for a node-level Review items
Verified on the current head: |
4caca49 to
5d89796
Compare
Exercises the property the RPC exists for and that a unit test cannot reach: an account derived from the wallet's master key is not watch-only, its funds are reported against it rather than the default account, and the wallet can sign a spend from it. An imported account gets as far as funding a PSBT, since that needs only public data, and fails at finalize; publishing the signed transaction and asserting it confirms is what separates the two. Also covers the requests lnd refuses: a duplicate name in any key scope, the wallet's reserved names, an empty name, and the strict nested-witness type, which a wallet-derived account cannot honour.
The address type is optional and defaults to taproot, matching the RPC, because the choice selects the account's key scope and is permanent for its lifetime.
5d89796 to
9664abd
Compare
|
|
||
| account := alice.RPC.XCreateAccount(&walletrpc.XCreateAccountRequest{ | ||
| Name: createAccountName, | ||
| AddressType: walletrpc.AddressType_TAPROOT_PUBKEY, |
There was a problem hiding this comment.
Ideally we could cover different address types too.
| be reproduced: accounts are derived from an index that btcwallet assigns | ||
| sequentially per key scope, shared with accounts created by ImportAccount. | ||
|
|
||
| To keep an account recoverable, record its key scope, the account index |
There was a problem hiding this comment.
Could we make the two address branches explicit in this recovery procedure? Account tracks external_key_count and internal_key_count separately, and NextAddr selects the branch with change (which defaults to false). Replaying a single aggregate count with the default therefore only recreates external addresses and can leave UTXOs on internal/change addresses invisible to the rescan—the likely location of most of the remaining balance after FundPsbt.
I think the actionable procedure should say to preserve both counters over the account lifetime, then call NextAddr(change=false) at least external_key_count times and NextAddr(change=true) at least internal_key_count times before rescanning. The lncli warning should reflect this too: recording only the derivation path at creation is not sufficient because both counters are zero then and grow as the account is used.
| maxCreateAccountSpendFee = btcutil.Amount(10_000) | ||
| ) | ||
|
|
||
| // testXCreateAccount asserts the end-to-end behaviour of an account created |
There was a problem hiding this comment.
Since the documented manual reconstruction is currently the only recovery mitigation, could we cover that procedure end to end as well? I am not suggesting that this PR must implement automatic seed-only discovery—the X/acknowledgement gates already make that limitation explicit—but the claim that the funds remain manually recoverable is important enough to verify before users rely on it.
The useful regression test would create a preceding account so the target has a non-trivial index, fund an external address, then spend through FundPsbt so value remains on an internal/change address. Record the target xpub plus both branch counts; restore the same seed into a fresh wallet DB; recreate accounts until the same scope/index is reached and assert the xpub matches; replay NextAddr(change=false) and NextAddr(change=true) using their respective counts; rescan with --reset-wallet-transactions; and finally assert that both balances are found and can be spent. Leaving real value on the internal branch is the part that would catch the recovery issue above.
|
@litbot-9000 re review |
|
Created backport PR for
Please cherry-pick the changes locally and resolve any conflicts. git fetch origin backport-11065-to-v0.21.x-branch
git worktree add --checkout .worktree/backport-11065-to-v0.21.x-branch backport-11065-to-v0.21.x-branch
cd .worktree/backport-11065-to-v0.21.x-branch
git reset --hard HEAD^
git cherry-pick -x 9664abd4f4c95d8e128853e0364e3b4e293fda68
git push --force-with-lease |
…21.x-branch [v0.21.x-branch] Backport #11065: walletrpc: add XCreateAccount for wallet-derived accounts
Motivation
lnd can already confine coin selection, change, balance, address derivation and
signing to a named wallet account —
FundPsbt,FinalizePsbt,ListUnspent,NextAddr,WalletBalanceandListTransactionsall take one. What is missingis a way to create such an account.
The only account-creating RPC today is
ImportAccount, which registers awatch-only account from an extended public key. The wallet stores no account
private key for it, so
waddrmgrcan only derive public keys for its addressesand
SignPsbt/FinalizePsbtsilently skip its inputs. That makes it unusablefor partitioning one wallet into isolated pockets of funds, which is what two
applications sharing a single lnd node need in order not to spend each other's
coins.
btcwallet already supports this via
Wallet.NextAccount, which derives a newaccount from the wallet's master key. lnd simply never exposed it.
Changes
Split so each commit stands alone:
walletrpc: define the XCreateAccount RPC— proto, REST annotation, regen.lnwallet: add CreateAccount to the WalletController interface— theBtcWalletimplementation, the two mocks, and an explicit refusal inRPCKeyRing.walletrpc: implement the XCreateAccount RPC— the handler.itest: cover XCreateAccount end to end.lncli: add wallet accounts create command.docs: add release notes for XCreateAccount.Notes for reviewers
Duplicates are rejected across all key scopes, not just the requested one.
Coin selection resolves a custom account through
lookupFirstCustomAccount,which returns whichever scope matches first, so the same name under two scopes
would make later funding calls ambiguous.
NESTED_WITNESS_PUBKEY_HASHis rejected. A wallet-derived account storesno address schema, so BIP-0049Plus always behaves as the hybrid scheme.
Accepting the strict type would hand back an account whose change outputs are
not what was asked for.
ImportAccountcan honour the distinction because itpasses an
addrSchemathrough;NextAccountcannot.Remote signing is refused explicitly.
RPCKeyRingembeds theWalletControllerinterface, so without an override it would promoteBtcWallet's implementation and fail deep insidewaddrmgrwith a bare"watching-only wallet". Creating the account on the signer and importing its
xpub here is the supported path.
Two caveats are documented in the proto and are worth a second opinion:
a later
NextAddrmust ask for the same address type or the account willappear not to exist.
account: lnd's recovery scan only derives addresses for account 0. Ordinary
rescans are unaffected.
BtcWallet.CreateAccountreachesNextAccountthrough a local interfaceassertion rather than widening btcwallet's
base.Interface, so lnd does nothave to carry a forked btcwallet — a
replacehere would not propagate tomodules that depend on lnd, and each of them would have to duplicate it. Happy
to do the btcwallet PR instead; the assertion is marked for removal if so.
Testing
Unit tests cover the guards, key-scope forwarding, the unsupported-wallet path
and error wrapping.
make rpc-checkand the REST-annotation check pass.There are also two itests, with the matching
HarnessRPChelpers. The firstcovers the property a unit test cannot reach: create →
NewAddress→ fund →FundPsbt→FinalizePsbt→ publish, asserting the balance lands on the newaccount and not the default one, and that the spend confirms. Funding a PSBT
works for a watch-only account too, since it needs only public data — it is
finalizing and confirming that separates a wallet-derived account from an
imported one. The second covers the refusals: duplicate names across key
scopes, reserved names, an empty name, and the strict nested-witness type.
🤖 Generated with Claude Code